diff --git a/swh/web/api/views/utils.py b/swh/web/api/views/utils.py
index 4d44f210..ef422a20 100644
--- a/swh/web/api/views/utils.py
+++ b/swh/web/api/views/utils.py
@@ -1,90 +1,90 @@
# Copyright (C) 2015-2017 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
from rest_framework.response import Response
from rest_framework.decorators import api_view
from types import GeneratorType
from swh.web.common.exc import NotFoundExc
from swh.web.api.apiurls import APIUrls, api_route
# canned doc string snippets that are used in several doc strings
doc_arg_content_id = """A "[hash_type:]hash" content identifier, where
hash_type is one of "sha1" (the default), "sha1_git", "sha256", and hash is
a checksum obtained with the hash_type hashing algorithm."""
doc_arg_last_elt = 'element to start listing from, for pagination purposes'
doc_arg_per_page = 'number of elements to list, for pagination purposes'
doc_exc_bad_id = 'syntax error in the given identifier(s)'
doc_exc_id_not_found = 'no object matching the given criteria could be found'
doc_ret_revision_meta = 'metadata of the revision identified by sha1_git'
doc_ret_revision_log = """list of dictionaries representing the metadata of
each revision found in the commit log heading to revision sha1_git.
For each commit at least the following information are returned:
author/committer, authoring/commit timestamps, revision id, commit message,
parent (i.e., immediately preceding) commits, "root" directory id."""
doc_header_link = """indicates that a subsequent result page is available,
pointing to it"""
def api_lookup(lookup_fn, *args,
notfound_msg='Object not found',
enrich_fn=lambda x: x):
"""Capture a redundant behavior of:
- looking up the backend with a criteria (be it an identifier or checksum)
passed to the function lookup_fn
- if nothing is found, raise an NotFoundExc exception with error
message notfound_msg.
- Otherwise if something is returned:
- either as list, map or generator, map the enrich_fn function to it
and return the resulting data structure as list.
- either as dict and pass to enrich_fn and return the dict enriched.
Args:
- criteria: discriminating criteria to lookup
- lookup_fn: function expects one criteria and optional supplementary
*args.
- notfound_msg: if nothing matching the criteria is found,
raise NotFoundExc with this error message.
- enrich_fn: Function to use to enrich the result returned by
lookup_fn. Default to the identity function if not provided.
- *args: supplementary arguments to pass to lookup_fn.
Raises:
NotFoundExp or whatever `lookup_fn` raises.
"""
res = lookup_fn(*args)
if not res:
raise NotFoundExc(notfound_msg)
if isinstance(res, (map, list, GeneratorType)):
return [enrich_fn(x) for x in res]
return enrich_fn(res)
@api_view(['GET', 'HEAD'])
def api_home(request):
return Response({}, template_name='api.html')
-APIUrls.add_url_pattern(r'^$', api_home, view_name='api_homepage')
+APIUrls.add_url_pattern(r'^$', api_home, view_name='api-homepage')
@api_route(r'/', 'endpoints')
def api_endpoints(request):
"""Display the list of opened api endpoints.
"""
routes = APIUrls.get_app_endpoints().copy()
for route, doc in routes.items():
doc['doc_intro'] = doc['docstring'].split('\n\n')[0]
# Return a list of routes with consistent ordering
env = {
'doc_routes': sorted(routes.items())
}
return Response(env, template_name="api-endpoints.html")
diff --git a/swh/web/browse/views/content.py b/swh/web/browse/views/content.py
index 88cd7847..d628bb25 100644
--- a/swh/web/browse/views/content.py
+++ b/swh/web/browse/views/content.py
@@ -1,155 +1,156 @@
# Copyright (C) 2017 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU General Public License version 3, or any later version
# See top-level LICENSE file for more information
from django.http import HttpResponse
from django.shortcuts import render
from django.template.defaultfilters import filesizeformat
from swh.model.hashutil import hash_to_hex
from swh.web.common import query
from swh.web.common.utils import reverse
from swh.web.common.exc import handle_view_exception
from swh.web.browse.utils import (
gen_path_info, request_content,
prepare_content_for_display
)
from swh.web.browse.browseurls import browse_route
@browse_route(r'content/(?P.+)/raw/',
view_name='browse-content-raw')
def content_raw(request, query_string):
"""Django view that produces a raw display of a SWH content identified
by its hash value.
The url that points to it is :http:get:`/browse/content/[(algo_hash):](hash)/raw/`
Args:
request: input django http request
query_string: a string of the form "[ALGO_HASH:]HASH" where
optional ALGO_HASH can be either *sha1*, *sha1_git*, *sha256*,
or *blake2s256* (default to *sha1*) and HASH the hexadecimal
representation of the hash value
Returns:
The raw bytes of the content.
""" # noqa
try:
algo, checksum = query.parse_hash(query_string)
checksum = hash_to_hex(checksum)
content_data = request_content(query_string)
except Exception as exc:
return handle_view_exception(request, exc)
filename = request.GET.get('filename', None)
if not filename:
filename = '%s_%s' % (algo, checksum)
if content_data['mimetype'].startswith('text/'):
response = HttpResponse(content_data['raw_data'],
content_type="text/plain")
response['Content-disposition'] = 'filename=%s' % filename
else:
response = HttpResponse(content_data['raw_data'],
content_type='application/octet-stream')
response['Content-disposition'] = 'attachment; filename=%s' % filename
return response
@browse_route(r'content/(?P.+)/',
view_name='browse-content')
def content_display(request, query_string):
"""Django view that produces an HTML display of a SWH content identified
by its hash value.
The url that points to it is :http:get:`/browse/content/[(algo_hash):](hash)/`
Args:
request: input django http request
query_string: a string of the form "[ALGO_HASH:]HASH" where
optional ALGO_HASH can be either *sha1*, *sha1_git*, *sha256*,
or *blake2s256* (default to *sha1*) and HASH the hexadecimal
representation of the hash value
Returns:
The HTML rendering of the requested content.
""" # noqa
try:
algo, checksum = query.parse_hash(query_string)
checksum = hash_to_hex(checksum)
content_data = request_content(query_string)
except Exception as exc:
return handle_view_exception(request, exc)
path = request.GET.get('path', None)
content_display_data = prepare_content_for_display(
content_data['raw_data'], content_data['mimetype'], path)
root_dir = None
filename = None
path_info = None
breadcrumbs = []
if path:
split_path = path.split('/')
root_dir = split_path[0]
filename = split_path[-1]
path = path.replace(root_dir + '/', '')
path = path.replace(filename, '')
path_info = gen_path_info(path)
breadcrumbs.append({'name': root_dir[:7],
'url': reverse('browse-directory',
kwargs={'sha1_git': root_dir})})
for pi in path_info:
breadcrumbs.append({'name': pi['name'],
'url': reverse('browse-directory',
kwargs={'sha1_git': root_dir,
'path': pi['path']})})
breadcrumbs.append({'name': filename,
'url': None})
query_params = None
if filename:
query_params = {'filename': filename}
content_raw_url = reverse('browse-content-raw',
kwargs={'query_string': query_string},
query_params=query_params)
content_metadata = {
'sha1 checksum': content_data['checksums']['sha1'],
'sha1_git checksum': content_data['checksums']['sha1_git'],
'sha256 checksum': content_data['checksums']['sha256'],
'blake2s256 checksum': content_data['checksums']['blake2s256'],
'mime type': content_data['mimetype'],
'encoding': content_data['encoding'],
'size': filesizeformat(content_data['length']),
'language': content_data['language'],
'licenses': content_data['licenses']
}
return render(request, 'content.html',
- {'top_panel_visible': True,
+ {'heading': 'Content information',
+ 'top_panel_visible': True,
'top_panel_collapsible': True,
'top_panel_text_left': 'SWH object: Content',
'top_panel_text_right': '%s: %s' % (algo, checksum),
'swh_object_metadata': content_metadata,
'main_panel_visible': True,
'content': content_display_data['content_data'],
'mimetype': content_data['mimetype'],
'language': content_display_data['language'],
'breadcrumbs': breadcrumbs,
'branches': None,
'branch': None,
'top_right_link': content_raw_url,
'top_right_link_text': 'Raw File'})
diff --git a/swh/web/browse/views/directory.py b/swh/web/browse/views/directory.py
index 8e874651..a1e5d546 100644
--- a/swh/web/browse/views/directory.py
+++ b/swh/web/browse/views/directory.py
@@ -1,97 +1,98 @@
# Copyright (C) 2017 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU General Public License version 3, or any later version
# See top-level LICENSE file for more information
from django.shortcuts import render
from django.template.defaultfilters import filesizeformat
from swh.web.common import service
from swh.web.common.utils import reverse
from swh.web.common.exc import handle_view_exception
from swh.web.browse.utils import (
gen_path_info, get_directory_entries
)
from swh.web.browse.browseurls import browse_route
@browse_route(r'directory/(?P[0-9a-f]+)/',
r'directory/(?P[0-9a-f]+)/(?P.+)/',
view_name='browse-directory')
def directory_browse(request, sha1_git, path=None):
"""Django view for browsing the content of a SWH directory identified
by its sha1_git value.
The url that points to it is :http:get:`/browse/directory/(sha1_git)/[(path)/]`
Args:
request: input django http request
sha1_git: swh sha1_git identifer of the directory to browse
path: optionnal path parameter used to navigate in directories
reachable from the provided root one
Returns:
The HTML rendering for the content of the provided directory.
""" # noqa
root_sha1_git = sha1_git
try:
if path:
dir_info = service.lookup_directory_with_path(sha1_git, path)
sha1_git = dir_info['target']
dirs, files = get_directory_entries(sha1_git)
except Exception as exc:
return handle_view_exception(request, exc)
path_info = gen_path_info(path)
breadcrumbs = []
breadcrumbs.append({'name': root_sha1_git[:7],
'url': reverse('browse-directory',
kwargs={'sha1_git': root_sha1_git})})
for pi in path_info:
breadcrumbs.append({'name': pi['name'],
'url': reverse('browse-directory',
kwargs={'sha1_git': root_sha1_git,
'path': pi['path']})})
path = '' if path is None else (path + '/')
for d in dirs:
d['url'] = reverse('browse-directory',
kwargs={'sha1_git': root_sha1_git,
'path': path + d['name']})
sum_file_sizes = 0
for f in files:
query_string = 'sha1_git:' + f['target']
f['url'] = reverse('browse-content',
kwargs={'query_string': query_string},
query_params={'path': root_sha1_git + '/' +
path + f['name']})
sum_file_sizes += f['length']
f['length'] = filesizeformat(f['length'])
sum_file_sizes = filesizeformat(sum_file_sizes)
dir_metadata = {'id': sha1_git,
'number of regular files': len(files),
'number of subdirectories': len(dirs),
'sum of regular file sizes': sum_file_sizes}
return render(request, 'directory.html',
- {'top_panel_visible': True,
+ {'heading': 'Directory information',
+ 'top_panel_visible': True,
'top_panel_collapsible': True,
'top_panel_text_left': 'SWH object: Directory',
'top_panel_text_right': 'Sha1 git: ' + sha1_git,
'swh_object_metadata': dir_metadata,
'main_panel_visible': True,
'dirs': dirs,
'files': files,
'breadcrumbs': breadcrumbs,
'branches': None,
'branch': None,
'top_right_link': None,
'top_right_link_text': None})
diff --git a/swh/web/browse/views/origin.py b/swh/web/browse/views/origin.py
index 4a24f9a4..853e9f85 100644
--- a/swh/web/browse/views/origin.py
+++ b/swh/web/browse/views/origin.py
@@ -1,607 +1,611 @@
# Copyright (C) 2017 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU General Public License version 3, or any later version
# See top-level LICENSE file for more information
import dateutil
from django.shortcuts import render
from django.template.defaultfilters import filesizeformat
from swh.web.common import service
from swh.web.common.utils import reverse, format_utc_iso_date
from swh.web.common.exc import NotFoundExc, handle_view_exception
from swh.web.browse.utils import (
get_origin_visits, get_origin_visit, get_origin_visit_branches,
gen_path_info, get_directory_entries, request_content,
prepare_content_for_display, gen_link,
prepare_revision_log_for_display
)
from swh.web.browse.browseurls import browse_route
@browse_route(r'origin/(?P[0-9]+)/',
r'origin/(?P[a-z]+)/url/(?P.+)/',
view_name='browse-origin')
def origin_browse(request, origin_id=None, origin_type=None,
origin_url=None):
"""Django view that produces an HTML display of a swh origin identified
by its id or its url.
The url scheme that points to it is :http:get:`/browse/origin/(origin_id)/`.
Args:
request: input django http request
origin_id: a swh origin id
origin_type: type of origin (git, svn, ...)
origin_url: url of the origin (e.g. https://github.com//)
Returns:
The HMTL rendering for the metadata of the provided origin.
""" # noqa
try:
if origin_id:
origin_request_params = {
'id': origin_id,
}
else:
origin_request_params = {
'type': origin_type,
'url': origin_url
}
origin_info = service.lookup_origin(origin_request_params)
origin_id = origin_info['id']
origin_visits = get_origin_visits(origin_id)
except Exception as exc:
return handle_view_exception(request, exc)
origin_info['last swh visit browse url'] = \
reverse('browse-origin-directory',
kwargs={'origin_id': origin_id})
origin_visits_data = []
for visit in origin_visits:
visit_date = dateutil.parser.parse(visit['date'])
visit['date'] = format_utc_iso_date(visit['date'])
visit['browse_url'] = reverse('browse-origin-directory',
kwargs={'origin_id': origin_id,
'visit_id': visit['visit']})
origin_visits_data.append(
{'date': visit_date.timestamp()})
return render(request, 'origin.html',
- {'top_panel_visible': True,
+ {'heading': 'Origin information',
+ 'top_panel_visible': True,
'top_panel_collapsible': True,
'top_panel_text_left': 'SWH object: Origin',
'top_panel_text_right': 'Url: ' + origin_info['url'],
'swh_object_metadata': origin_info,
'main_panel_visible': True,
'origin_visits_data': origin_visits_data,
'visits': list(reversed(origin_visits)),
'browse_url_base': '/browse/origin/%s/' %
origin_id})
def _get_origin_branches_and_url_args(origin_id, visit_id, ts):
if not visit_id and ts:
branches = get_origin_visit_branches(origin_id, visit_ts=ts)
url_args = {'origin_id': origin_id,
'timestamp': ts}
else:
branches = get_origin_visit_branches(origin_id, visit_id)
url_args = {'origin_id': origin_id,
'visit_id': visit_id}
return branches, url_args
def _raise_exception_if_branch_not_found(origin_id, visit_id, ts, branch):
if visit_id:
raise NotFoundExc('Branch %s associated to visit with'
' id %s for origin with id %s'
' not found!' %
(branch, visit_id, origin_id))
else:
raise NotFoundExc('Branch %s associated to visit with'
' timestamp %s for origin with id %s'
' not found!' %
(branch, ts, origin_id))
def _get_branch(branches, branch_name):
"""
Utility function to get a specific branch from an origin branches list.
Its purpose is to get the default HEAD branch as some SWH origin
(e.g those with svn type) does not have it. In that latter case, check
if there is a master branch instead and returns it.
"""
filtered_branches = [b for b in branches if b['name'].endswith(branch_name)] # noqa
if len(filtered_branches) > 0:
return filtered_branches[0]
elif branch_name == 'HEAD':
filtered_branches = [b for b in branches if b['name'].endswith('master')] # noqa
if len(filtered_branches) > 0:
return filtered_branches[0]
return None
def _gen_origin_link(origin_id, origin_url):
origin_browse_url = reverse('browse-origin',
kwargs={'origin_id': origin_id})
return gen_link(origin_browse_url,
'Origin: ' + origin_url)
@browse_route(r'origin/(?P[0-9]+)/directory/',
r'origin/(?P[0-9]+)/directory/(?P.+)/',
r'origin/(?P[0-9]+)/visit/(?P[0-9]+)/directory/', # noqa
r'origin/(?P[0-9]+)/visit/(?P[0-9]+)/directory/(?P.+)/', # noqa
r'origin/(?P[0-9]+)/ts/(?P.+)/directory/', # noqa
r'origin/(?P[0-9]+)/ts/(?P.+)/directory/(?P.+)/', # noqa
view_name='browse-origin-directory')
def origin_directory_browse(request, origin_id, visit_id=None,
timestamp=None, path=None):
"""Django view for browsing the content of a swh directory associated
to an origin for a given visit.
The url scheme that points to it is the following:
* :http:get:`/browse/origin/(origin_id)/directory/[(path)/]`
* :http:get:`/browse/origin/(origin_id)/visit/(visit_id)/directory/[(path)/]`
* :http:get:`/browse/origin/(origin_id)/ts/(timestamp)/directory/[(path)/]`
Args:
request: input django http request
origin_id: a swh origin id
visit_id: optionnal visit id parameter
(the last one will be used by default)
timestamp: optionnal visit timestamp parameter
(the last one will be used by default)
path: optionnal path parameter used to navigate in directories
reachable from the origin root one
branch: optionnal query parameter that specifies the origin branch
from which to retrieve the directory
revision: optional query parameter to specify the origin revision
from which to retrieve the directory
Returns:
The HTML rendering for the content of the directory associated
to the provided origin and visit.
""" # noqa
try:
if not visit_id and not timestamp:
origin_visits = get_origin_visits(origin_id)
if not origin_visits:
raise NotFoundExc('No SWH visit associated to '
'origin with id %s' % origin_id)
return origin_directory_browse(request, origin_id,
origin_visits[-1]['visit'],
path=path)
origin_info = service.lookup_origin({'id': origin_id})
branches, url_args = \
_get_origin_branches_and_url_args(origin_id, visit_id, timestamp)
visit_info = get_origin_visit(origin_id, visit_id, timestamp)
for b in branches:
branch_url_args = dict(url_args)
if path:
b['path'] = path
branch_url_args['path'] = path
b['url'] = reverse('browse-origin-directory',
kwargs=branch_url_args,
query_params={'branch': b['name']})
revision_id = request.GET.get('revision', None)
if revision_id:
revision = service.lookup_revision(revision_id)
root_sha1_git = revision['directory']
branches.append({'name': revision_id,
'revision': revision_id,
'directory': root_sha1_git,
'url': None})
branch_name = revision_id
else:
branch_name = request.GET.get('branch', 'HEAD')
branch = _get_branch(branches, branch_name)
if branch:
branch_name = branch['name']
root_sha1_git = branch['directory']
else:
_raise_exception_if_branch_not_found(origin_id, visit_id,
timestamp, branch_name)
sha1_git = root_sha1_git
if path:
dir_info = service.lookup_directory_with_path(root_sha1_git, path)
sha1_git = dir_info['target']
dirs, files = get_directory_entries(sha1_git)
except Exception as exc:
return handle_view_exception(request, exc)
if revision_id:
query_params = {'revision': revision_id}
else:
query_params = {'branch': branch_name}
path_info = gen_path_info(path)
breadcrumbs = []
breadcrumbs.append({'name': root_sha1_git[:7],
'url': reverse('browse-origin-directory',
kwargs=url_args,
query_params=query_params)})
for pi in path_info:
bc_url_args = dict(url_args)
bc_url_args['path'] = pi['path']
breadcrumbs.append({'name': pi['name'],
'url': reverse('browse-origin-directory',
kwargs=bc_url_args,
query_params=query_params)})
path = '' if path is None else (path + '/')
for d in dirs:
bc_url_args = dict(url_args)
bc_url_args['path'] = path + d['name']
d['url'] = reverse('browse-origin-directory',
kwargs=bc_url_args,
query_params=query_params)
sum_file_sizes = 0
for f in files:
bc_url_args = dict(url_args)
bc_url_args['path'] = path + f['name']
f['url'] = reverse('browse-origin-content',
kwargs=bc_url_args,
query_params=query_params)
sum_file_sizes += f['length']
f['length'] = filesizeformat(f['length'])
history_url = reverse('browse-origin-log',
kwargs=url_args,
query_params=query_params)
sum_file_sizes = filesizeformat(sum_file_sizes)
dir_metadata = {'id': sha1_git,
'number of regular files': len(files),
'number of subdirectories': len(dirs),
'sum of regular file sizes': sum_file_sizes,
'origin id': origin_info['id'],
'origin type': origin_info['type'],
'origin url': origin_info['url'],
'origin visit': format_utc_iso_date(visit_info['date']),
'path': '/' + path}
return render(request, 'directory.html',
- {'top_panel_visible': True,
+ {'heading': 'Directory information',
+ 'top_panel_visible': True,
'top_panel_collapsible': True,
'top_panel_text_left': 'SWH object: Directory',
'top_panel_text_right': _gen_origin_link(
origin_id, origin_info['url']),
'swh_object_metadata': dir_metadata,
'main_panel_visible': True,
'dirs': dirs,
'files': files,
'breadcrumbs': breadcrumbs,
'branches': branches,
'branch': branch_name,
'top_right_link': history_url,
'top_right_link_text': 'History'})
@browse_route(r'origin/(?P[0-9]+)/content/(?P.+)/',
r'origin/(?P[0-9]+)/visit/(?P[0-9]+)/content/(?P.+)/', # noqa
r'origin/(?P[0-9]+)/ts/(?P.+)/content/(?P.+)/', # noqa
view_name='browse-origin-content')
def origin_content_display(request, origin_id, path,
visit_id=None, timestamp=None):
"""Django view that produces an HTML display of a swh content
associated to an origin for a given visit.
The url scheme that points to it is the following:
* :http:get:`/browse/origin/(origin_id)/content/(path)/`
* :http:get:`/browse/origin/(origin_id)/visit/(visit_id)/content/(path)/`
* :http:get:`/browse/origin/(origin_id)/ts/(timestamp)/content/(path)/`
Args:
request: input django http request
origin_id: id of a swh origin
path: path of the content relative to the origin root directory
visit_id: optionnal visit id parameter
(the last one will be used by default)
timestamp: optionnal visit timestamp parameter
(the last one will be used by default)
branch: optionnal query parameter that specifies the origin branch
from which to retrieve the content
revision: optional query parameter to specify the origin revision
from which to retrieve the content
Returns:
The HTML rendering of the requested content associated to
the provided origin and visit.
""" # noqa
try:
if not visit_id and not timestamp:
origin_visits = get_origin_visits(origin_id)
if not origin_visits:
raise NotFoundExc('No SWH visit associated to '
'origin with id %s' % origin_id)
return origin_content_display(request, origin_id, path,
origin_visits[-1]['visit'])
origin_info = service.lookup_origin({'id': origin_id})
branches, url_args = \
_get_origin_branches_and_url_args(origin_id, visit_id, timestamp)
visit_info = get_origin_visit(origin_id, visit_id, timestamp)
for b in branches:
bc_url_args = dict(url_args)
bc_url_args['path'] = path
b['url'] = reverse('browse-origin-content',
kwargs=bc_url_args,
query_params={'branch': b['name']})
revision_id = request.GET.get('revision', None)
if revision_id:
revision = service.lookup_revision(revision_id)
root_sha1_git = revision['directory']
branches.append({'name': revision_id,
'revision': revision_id,
'directory': root_sha1_git,
'url': None})
branch_name = revision_id
else:
branch_name = request.GET.get('branch', 'HEAD')
branch = _get_branch(branches, branch_name)
if branch:
branch_name = branch['name']
root_sha1_git = branch['directory']
else:
_raise_exception_if_branch_not_found(origin_id, visit_id,
timestamp, branch_name)
content_info = service.lookup_directory_with_path(root_sha1_git, path)
sha1_git = content_info['target']
query_string = 'sha1_git:' + sha1_git
content_data = request_content(query_string)
except Exception as exc:
return handle_view_exception(request, exc)
if revision_id:
query_params = {'revision': revision_id}
else:
query_params = {'branch': branch_name}
content_display_data = prepare_content_for_display(
content_data['raw_data'], content_data['mimetype'], path)
filename = None
path_info = None
breadcrumbs = []
split_path = path.split('/')
filename = split_path[-1]
path = path.replace(filename, '')
path_info = gen_path_info(path)
breadcrumbs.append({'name': root_sha1_git[:7],
'url': reverse('browse-origin-directory',
kwargs=url_args,
query_params=query_params)})
for pi in path_info:
bc_url_args = dict(url_args)
bc_url_args['path'] = pi['path']
breadcrumbs.append({'name': pi['name'],
'url': reverse('browse-origin-directory',
kwargs=bc_url_args,
query_params=query_params)})
breadcrumbs.append({'name': filename,
'url': None})
content_raw_url = reverse('browse-content-raw',
kwargs={'query_string': query_string},
query_params={'filename': filename})
content_metadata = {
'sha1 checksum': content_data['checksums']['sha1'],
'sha1_git checksum': content_data['checksums']['sha1_git'],
'sha256 checksum': content_data['checksums']['sha256'],
'blake2s256 checksum': content_data['checksums']['blake2s256'],
'mime type': content_data['mimetype'],
'encoding': content_data['encoding'],
'size': filesizeformat(content_data['length']),
'language': content_data['language'],
'licenses': content_data['licenses'],
'origin id': origin_info['id'],
'origin type': origin_info['type'],
'origin url': origin_info['url'],
'origin visit': format_utc_iso_date(visit_info['date']),
'path': '/' + path,
'filename': filename
}
return render(request, 'content.html',
- {'top_panel_visible': True,
+ {'heading': 'Content information',
+ 'top_panel_visible': True,
'top_panel_collapsible': True,
'top_panel_text_left': 'SWH object: Content',
'top_panel_text_right': _gen_origin_link(
origin_id, origin_info['url']),
'swh_object_metadata': content_metadata,
'main_panel_visible': True,
'content': content_display_data['content_data'],
'mimetype': content_data['mimetype'],
'language': content_display_data['language'],
'breadcrumbs': breadcrumbs,
'branches': branches,
'branch': branch_name,
'top_right_link': content_raw_url,
'top_right_link_text': 'Raw File'})
def _gen_directory_link(url_args, revision, link_text):
directory_url = reverse('browse-origin-directory',
kwargs=url_args,
query_params={'revision': revision})
return gen_link(directory_url, link_text)
NB_LOG_ENTRIES = 20
@browse_route(r'origin/(?P[0-9]+)/log/',
r'origin/(?P[0-9]+)/visit/(?P[0-9]+)/log/', # noqa
r'origin/(?P[0-9]+)/ts/(?P.+)/log/',
view_name='browse-origin-log')
def origin_log_browse(request, origin_id, visit_id=None, timestamp=None):
"""Django view that produces an HTML display of revisions history (aka
the commit log) associated to a SWH origin.
The url scheme that points to it is the following:
* :http:get:`/browse/origin/(origin_id)/log/`
* :http:get:`/browse/origin/(origin_id)/visit/(visit_id)/log/`
* :http:get:`/browse/origin/(origin_id)/ts/(timestamp)/log/`
Args:
request: input django http request
origin_id: id of a swh origin
visit_id: optionnal visit id parameter
(the last one will be used by default)
timestamp: optionnal visit timestamp parameter
(the last one will be used by default)
revs_breadcrumb: query parameter used internally to store
the navigation breadcrumbs (i.e. the list of descendant revisions
visited so far).
per_page: optionnal query parameter used to specify the number of
log entries per page
branch: optionnal query parameter that specifies the origin branch
from which to retrieve the content
revision: optional query parameter to specify the origin revision
from which to retrieve the directory
Returns:
The HTML rendering of revisions history for a given SWH visit.
""" # noqa
try:
if not visit_id and not timestamp:
origin_visits = get_origin_visits(origin_id)
if not origin_visits:
raise NotFoundExc('No SWH visit associated to '
'origin with id %s' % origin_id)
return origin_log_browse(request, origin_id,
origin_visits[-1]['visit'])
branches, url_args = \
_get_origin_branches_and_url_args(origin_id, visit_id, timestamp)
visit_info = get_origin_visit(origin_id, visit_id, timestamp)
for b in branches:
b['url'] = reverse('browse-origin-log',
kwargs=url_args,
query_params={'branch': b['name']})
revision_id = request.GET.get('revision', None)
revs_breadcrumb = request.GET.get('revs_breadcrumb', None)
branch_name = request.GET.get('branch', 'HEAD')
if revision_id:
revision = service.lookup_revision(revision_id)
branches.append({'name': revision_id,
'revision': revision_id,
'directory': revision['directory']})
revision = revision_id
branch_name = revision_id
elif revs_breadcrumb:
revs = revs_breadcrumb.split('/')
revision = revs[-1]
else:
branch = _get_branch(branches, branch_name)
if branch:
branch_name = branch['name']
revision = branch['revision']
else:
_raise_exception_if_branch_not_found(origin_id, visit_id,
timestamp, branch_name)
per_page = int(request.GET.get('per_page', NB_LOG_ENTRIES))
revision_log = service.lookup_revision_log(revision,
limit=per_page+1)
revision_log = list(revision_log)
except Exception as exc:
return handle_view_exception(request, exc)
revision_log_display_data = prepare_revision_log_for_display(
revision_log, per_page, revs_breadcrumb, origin_context=True)
prev_rev = revision_log_display_data['prev_rev']
prev_revs_breadcrumb = revision_log_display_data['prev_revs_breadcrumb']
prev_log_url = None
if prev_rev:
prev_log_url = \
reverse('browse-origin-log',
kwargs=url_args,
query_params={'revs_breadcrumb': prev_revs_breadcrumb,
'per_page': per_page,
'branch': branch_name})
next_rev = revision_log_display_data['next_rev']
next_revs_breadcrumb = revision_log_display_data['next_revs_breadcrumb']
next_log_url = None
if next_rev:
next_log_url = \
reverse('browse-origin-log',
kwargs=url_args,
query_params={'revs_breadcrumb': next_revs_breadcrumb,
'per_page': per_page,
'branch': branch_name})
revision_log_data = revision_log_display_data['revision_log_data']
for i, log in enumerate(revision_log_data):
log['directory'] = _gen_directory_link(url_args, revision_log[i]['id'],
'Tree')
origin_info = service.lookup_origin({'id': origin_id})
revision_metadata = {
'origin id': origin_info['id'],
'origin type': origin_info['type'],
'origin url': origin_info['url'],
'origin visit': format_utc_iso_date(visit_info['date'])
}
return render(request, 'revision-log.html',
- {'top_panel_visible': True,
+ {'heading': 'Revision history information',
+ 'top_panel_visible': True,
'top_panel_collapsible': True,
'top_panel_text_left': 'SWH object: Revision history',
'top_panel_text_right': _gen_origin_link(
origin_id, origin_info['url']),
'swh_object_metadata': revision_metadata,
'main_panel_visible': True,
'revision_log': revision_log_data,
'next_log_url': next_log_url,
'prev_log_url': prev_log_url,
'breadcrumbs': None,
'branches': branches,
'branch': branch_name,
'top_right_link': None,
'top_right_link_text': None,
'include_top_navigation': True,
'no_origin_context': False})
diff --git a/swh/web/browse/views/person.py b/swh/web/browse/views/person.py
index d79b1e53..f7e37476 100644
--- a/swh/web/browse/views/person.py
+++ b/swh/web/browse/views/person.py
@@ -1,40 +1,41 @@
# Copyright (C) 2017 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU General Public License version 3, or any later version
# See top-level LICENSE file for more information
from django.shortcuts import render
from swh.web.common import service
from swh.web.common.exc import handle_view_exception
from swh.web.browse.browseurls import browse_route
@browse_route(r'person/(?P[0-9]+)/',
view_name='browse-person')
def person_browse(request, person_id):
"""
Django view that produces an HTML display of a swh person
identified by its id.
The url that points to it is :http:get:`/browse/person/(person_id)/`.
Args:
request: input django http request
person_id (int): a swh person id
Returns:
The HMTL rendering for the metadata of the provided person.
"""
try:
person = service.lookup_person(person_id)
except Exception as exc:
return handle_view_exception(request, exc)
return render(request, 'person.html',
- {'top_panel_visible': True,
+ {'heading': 'Person information',
+ 'top_panel_visible': True,
'top_panel_collapsible': False,
'top_panel_text_left': 'SWH object: Person',
'top_panel_text_right': 'Name: ' + person['name'],
'swh_object_metadata': person,
'main_panel_visible': False})
diff --git a/swh/web/browse/views/revision.py b/swh/web/browse/views/revision.py
index e1aaa2ea..563ec8bd 100644
--- a/swh/web/browse/views/revision.py
+++ b/swh/web/browse/views/revision.py
@@ -1,164 +1,166 @@
# Copyright (C) 2017 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU General Public License version 3, or any later version
# See top-level LICENSE file for more information
import json
from django.shortcuts import render
from django.utils.safestring import mark_safe
from swh.web.common import service
from swh.web.common.utils import reverse, format_utc_iso_date
from swh.web.common.exc import handle_view_exception
from swh.web.browse.browseurls import browse_route
from swh.web.browse.utils import (
gen_link, gen_person_link, gen_revision_link,
prepare_revision_log_for_display
)
def _gen_directory_link(sha1_git, link_text):
directory_url = reverse('browse-directory',
kwargs={'sha1_git': sha1_git})
return gen_link(directory_url, link_text)
def _gen_revision_log_link(revision_id):
revision_log_url = reverse('browse-revision-log',
kwargs={'sha1_git': revision_id})
return gen_link(revision_log_url, revision_log_url)
@browse_route(r'revision/(?P[0-9a-f]+)/',
view_name='browse-revision')
def revision_browse(request, sha1_git):
"""
Django view that produces an HTML display of a SWH revision
identified by its id.
The url that points to it is :http:get:`/browse/revision/(sha1_git)/`.
Args:
request: input django http request
sha1_git: a SWH revision id
Returns:
The HMTL rendering for the metadata of the provided revision.
"""
try:
revision = service.lookup_revision(sha1_git)
except Exception as exc:
return handle_view_exception(request, exc)
revision_data = {}
revision_data['author'] = gen_person_link(
revision['author']['id'], revision['author']['name'])
revision_data['committer'] = gen_person_link(
revision['committer']['id'], revision['committer']['name'])
revision_data['committer date'] = format_utc_iso_date(
revision['committer_date'])
revision_data['date'] = format_utc_iso_date(revision['date'])
revision_data['directory'] = _gen_directory_link(revision['directory'],
revision['directory'])
revision_data['history log'] = _gen_revision_log_link(sha1_git)
revision_data['id'] = sha1_git
revision_data['merge'] = revision['merge']
revision_data['message'] = revision['message']
revision_data['metadata'] = json.dumps(revision['metadata'],
sort_keys=True,
indent=4, separators=(',', ': '))
parents = ''
for p in revision['parents']:
parent_link = gen_revision_link(p)
parents += parent_link + ' '
revision_data['parents'] = mark_safe(parents)
revision_data['synthetic'] = revision['synthetic']
revision_data['type'] = revision['type']
return render(request, 'revision.html',
- {'top_panel_visible': False,
+ {'heading': 'Revision information',
+ 'top_panel_visible': False,
'top_panel_collapsible': False,
'top_panel_text_left': 'SWH object: Revision',
'top_panel_text_right': 'Sha1 git: ' + sha1_git,
'swh_object_metadata': None,
'main_panel_visible': True,
'revision': revision_data})
NB_LOG_ENTRIES = 20
@browse_route(r'revision/(?P[0-9a-f]+)/log/',
view_name='browse-revision-log')
def revision_log_browse(request, sha1_git):
"""
Django view that produces an HTML display of the history
log for a SWH revision identified by its id.
The url that points to it is :http:get:`/browse/revision/(sha1_git)/log/`.
Args:
request: input django http request
sha1_git: a SWH revision id
Returns:
The HMTL rendering of the revision history log.
""" # noqa
try:
per_page = int(request.GET.get('per_page', NB_LOG_ENTRIES))
revision_log = service.lookup_revision_log(sha1_git,
limit=per_page+1)
revision_log = list(revision_log)
except Exception as exc:
return handle_view_exception(request, exc)
revs_breadcrumb = request.GET.get('revs_breadcrumb', None)
revision_log_display_data = prepare_revision_log_for_display(
revision_log, per_page, revs_breadcrumb)
prev_rev = revision_log_display_data['prev_rev']
prev_revs_breadcrumb = revision_log_display_data['prev_revs_breadcrumb']
prev_log_url = None
if prev_rev:
prev_log_url = \
reverse('browse-revision-log',
kwargs={'sha1_git': prev_rev},
query_params={'revs_breadcrumb': prev_revs_breadcrumb,
'per_page': per_page})
next_rev = revision_log_display_data['next_rev']
next_revs_breadcrumb = revision_log_display_data['next_revs_breadcrumb']
next_log_url = None
if next_rev:
next_log_url = \
reverse('browse-revision-log',
kwargs={'sha1_git': next_rev},
query_params={'revs_breadcrumb': next_revs_breadcrumb,
'per_page': per_page})
revision_log_data = revision_log_display_data['revision_log_data']
for log in revision_log_data:
log['directory'] = _gen_directory_link(log['directory'], 'Tree')
return render(request, 'revision-log.html',
- {'top_panel_visible': False,
+ {'heading': 'Revision history information',
+ 'top_panel_visible': False,
'top_panel_collapsible': False,
'top_panel_text_left': 'SWH object: Revision history',
'top_panel_text_right': 'Sha1 git: ' + sha1_git,
'swh_object_metadata': None,
'main_panel_visible': True,
'revision_log': revision_log_data,
'next_log_url': next_log_url,
'prev_log_url': prev_log_url,
'breadcrumbs': None,
'branches': None,
'branch': None,
'top_right_link': None,
'top_right_link_text': None,
'include_top_navigation': False,
'no_origin_context': True})
diff --git a/swh/web/static/img/swh-api.png b/swh/web/static/img/swh-api.png
new file mode 100644
index 00000000..29da830a
Binary files /dev/null and b/swh/web/static/img/swh-api.png differ
diff --git a/swh/web/static/img/swh-browse.png b/swh/web/static/img/swh-browse.png
new file mode 100644
index 00000000..19f39701
Binary files /dev/null and b/swh/web/static/img/swh-browse.png differ
diff --git a/swh/web/templates/browse.html b/swh/web/templates/browse.html
index 49df049b..6f8f0049 100644
--- a/swh/web/templates/browse.html
+++ b/swh/web/templates/browse.html
@@ -1,54 +1,56 @@
{% extends "layout.html" %}
{% load swh_templatetags %}
+{% block title %}{{ heading }} – Software Heritage archive {% endblock %}
+
{% block content %}
{% endblock %}
\ No newline at end of file
diff --git a/swh/web/templates/homepage.html b/swh/web/templates/homepage.html
new file mode 100644
index 00000000..d98ce96d
--- /dev/null
+++ b/swh/web/templates/homepage.html
@@ -0,0 +1,58 @@
+{% extends "layout.html" %}
+{% load static %}
+{% block title %}The Software Heritage archive{% endblock %}
+{% block content %}
+
Welcolme to the Software Heritage archive
+
+
Overview
+
+
+The long term goal of the Software Heritage initiative is to collect
+all publicly available software in source code form together with its
+development history, replicate it massively to ensure its preservation,
+and share it with everyone who needs it.
+
+The Software Heritage archive is growing over time as we crawl new source code from software
+projects and development forges. We will incrementally release archive search
+and browse functionalities — as of now you can check whether source code you care
+about is already present in the archive or not.
+
+
+
Content
+
+
+A significant amount of source code has already been ingested in the Software Heritage
+archive. It currently includes:
+
You can jump directly to the endpoint index, which lists all available API functionalities, or read on for more general information about the API.
Data model
The Software Heritage project harvests publicly available source code by tracking software distribution channels such as version control systems, tarball releases, and distribution packages.
All retrieved source code and related metadata are stored in the Software Heritage archive, that is conceptually a Merkle DAG. All nodes in the graph are content-addressable, i.e., their node identifiers are computed by hashing their content and, transitively, that of all nodes reachable from them; and no node or edge is ever removed from the graph: the Software Heritage archive is an append-only data structure.
The following types of objects (i.e., graph nodes) can be found in the Software Heritage archive (for more information see the Software Heritage glossary):
Content: a specific version of a file stored in the archive, identified by its cryptographic hashes (currently: SHA1, Git-like "salted" SHA1, SHA256). Note that content objects are nameless; their names are context-dependent and stored as part of directory entries (see below). Also known as: "blob"
Directory: a list of directory entries, where each entry can point to content objects ("file entries"), revisions ("revision entries"), or transitively to other directories ("directory entries"). All entries are associated to the local name of the entry (i.e., a relative path without any path separator) and permission metadata (e.g., chmod value or equivalent).
Revision: a point in time snapshot of the content of a directory, together with associated development metadata (e.g., author, timestamp, log message, etc). Also known as: "commit".
Release: a revision that has been marked as noteworthy with a specific name (e.g., a version number), together with associated development metadata (e.g., author, timestamp, etc). Also known as: "tag"
Origin: an Internet-based location from which a coherent set of objects (contents, revisions, releases, etc.) archived by Software Heritage has been obtained. Origins are currently identified by URLs.
Visit: the passage of Software Heritage on a given origin, to retrieve all source code and metadata available there at the time. A visit object stores the state of all visible branches (if any) available at the origin at visit time; each of them points to a revision object in the archive. Future visits of the same origin will create new visit objects, without removing previous ones.
Person: an entity referenced by a revision as either the author or the committer of the corresponding change. A person is associated to a full name and/or an email address.
Version
The current version of the API is v1.
Warning: this version of the API is not to be considered stable yet. Non-backward compatible changes might happen even without changing the API version number.
The response format can be overridden using the Accept request header. In particular, Accept: text/html (that web browsers send by default) requests HTML pretty-printing, whereas Accept: application/yaml requests YAML-encoded responses.
Some API endpoints can be tweaked by passing optional parameters. For GET requests, optional parameters can be passed as an HTTP query string.
The optional parameter fields is accepted by all endpoints that return dictionaries and can be used to restrict the list of fields returned by the API, in case you are not interested in all of them. By default, all available fields are returned.
Unavailability of the underlying storage backend will result in a 503 Service Unavailable HTTP response.
Pagination
Requests that might potentially return many items will be paginated.
Page size is set to a default (usually: 10 items), but might be overridden with the per_page query parameter up to a maximum (usually: 50 items). Example:
Due to limited resource availability on the back end side, API usage is currently rate limited. Furthermore, as API usage is currently entirely anonymous (i.e., without any authentication), API "users" are currently identified by their origin IP address.
Three HTTP response fields will inform you about the current state of limits that apply to your current rate limiting bucket:
X-RateLimit-Limit: maximum number of permitted requests per hour
X-RateLimit-Remaining: number of permitted requests remaining before the next reset
X-RateLimit-Reset: the time (expressed in Unix time seconds) at which the current rate limiting will expire, resetting to a fresh X-RateLimit-Limit
### Endpoint index
You can jump directly to the endpoint
index, which lists all available API functionalities, or read on
for more general information about the API.
### Data model
The [Software Heritage](https://www.softwareheritage.org/) project harvests
publicly available source code by tracking software distribution channels such
as version control systems, tarball releases, and distribution packages.
All retrieved source code and related metadata are stored in the Software
Heritage archive, that is conceptually
a [Merkle DAG](https://en.wikipedia.org/wiki/Merkle_tree). All nodes in the
graph are content-addressable, i.e., their node identifiers are computed by
hashing their content and, transitively, that of all nodes reachable from them;
and no node or edge is ever removed from the graph: the Software Heritage
archive is an append-only data structure.
The following types of objects (i.e., graph nodes) can be found in the Software
Heritage archive (for more information see
the
[Software Heritage glossary](https://wiki.softwareheritage.org/index.php?title=Glossary)):
- **Content**: a specific version of a file stored in the archive, identified
by its cryptographic hashes (currently: SHA1, Git-like "salted" SHA1,
SHA256). Note that content objects are nameless; their names are
context-dependent and stored as part of directory entries (see below).
*Also known as:* "blob"
- **Directory**: a list of directory entries, where each entry can point to
content objects ("file entries"), revisions ("revision entries"), or
transitively to other directories ("directory entries"). All entries are
associated to the local name of the entry (i.e., a relative path without any
path separator) and permission metadata (e.g., chmod value or equivalent).
- **Revision**: a point in time snapshot of the content of a directory,
together with associated development metadata (e.g., author, timestamp, log
message, etc).
*Also known as:* "commit".
- **Release**: a revision that has been marked as noteworthy with a specific
name (e.g., a version number), together with associated development metadata
(e.g., author, timestamp, etc).
*Also known as:* "tag"
- **Origin**: an Internet-based location from which a coherent set of objects
(contents, revisions, releases, etc.) archived by Software Heritage has been
obtained. Origins are currently identified by URLs.
- **Visit**: the passage of Software Heritage on a given origin, to retrieve
all source code and metadata available there at the time. A visit object
stores the state of all visible branches (if any) available at the origin at
visit time; each of them points to a revision object in the archive. Future
visits of the same origin will create new visit objects, without removing
previous ones.
- **Person**: an entity referenced by a revision as either the author or the
committer of the corresponding change. A person is associated to a full name
and/or an email address.
### Version
The current version of the API is **v1**.
**Warning:** this version of the API is not to be considered stable yet.
Non-backward compatible changes might happen even without changing the API
version number.
### Schema
API access is over HTTPS.
All API endpoints are rooted at .
Data is sent and received as JSON by default.
Example:
- from the command line:
``` shell
curl -i https://archive.softwareheritage.org/api/1/stat/counters/
```
#### Response format override
The response format can be overridden using the `Accept` request header. In
particular, `Accept: text/html` (that web browsers send by default) requests
HTML pretty-printing, whereas `Accept: application/yaml` requests YAML-encoded
responses.
Example:
- [/api/1/stat/counters/](/api/1/stat/counters/)
- from the command line:
``` shell
curl -i -H 'Accept: application/yaml' https://archive.softwareheritage.org/api/1/stat/counters/
```
### Parameters
Some API endpoints can be tweaked by passing optional parameters. For GET
requests, optional parameters can be passed as an HTTP query string.
The optional parameter `fields` is accepted by all endpoints that return
dictionaries and can be used to restrict the list of fields returned by the
API, in case you are not interested in all of them. By default, all available
fields are returned.
Example:
- [/api/1/stat/counters/\?fields\=content,directory,revision](/api/1/stat/counters/?fields=content,directory,revision)
- from the command line:
``` shell
curl https://archive.softwareheritage.org/api/1/stat/counters/?fields=content,directory,revision
```
### Errors
While API endpoints will return different kinds of errors depending on their
own semantics, some error patterns are common across all endpoints.
Sending malformed data, including syntactically incorrect object identifiers,
will result in a `400 Bad Request` HTTP response. Example:
- [/api/1/content/deadbeef/](/api/1/content/deadbeef/) (client error:
"deadbeef" is too short to be a syntactically valid object identifier)
- from the command line:
``` shell
curl -i https://archive.softwareheritage.org/api/1/content/deadbeef/
```
Requesting non existent resources will result in a `404 Not Found` HTTP
response. Example:
- [/api/1/content/0123456789abcdef0123456789abcdef01234567/](/api/1/content/0123456789abcdef0123456789abcdef01234567/)
(error: no object with that identifier is available [yet?])
- from the command line:
``` shell
curl -i https://archive.softwareheritage.org/api/1/content/04740277a81c5be6c16f6c9da488ca073b770d7f/
```
Unavailability of the underlying storage backend will result in a `503 Service
Unavailable` HTTP response.
### Pagination
Requests that might potentially return many items will be paginated.
Page size is set to a default (usually: 10 items), but might be overridden with
the `per_page` query parameter up to a maximum (usually: 50 items). Example:
``` shell
curl https://archive.softwareheritage.org/api/1/origin/1/visits/?per_page=2
```
To navigate through paginated results, a `Link` HTTP response header is
available to link the current result page to the next one. Example:
curl -i https://archive.softwareheritage.org/api/1/origin/1/visits/?per_page=2 | grep ^Link:
Link: ; rel="next",
### Rate limiting
Due to limited resource availability on the back end side, API usage is
currently rate limited. Furthermore, as API usage is currently entirely
anonymous (i.e., without any authentication), API "users" are currently
identified by their origin IP address.
Three HTTP response fields will inform you about the current state of limits
that apply to your current rate limiting bucket:
- `X-RateLimit-Limit`: maximum number of permitted requests per hour
- `X-RateLimit-Remaining`: number of permitted requests remaining before the
next reset
- `X-RateLimit-Reset`: the time (expressed
in [Unix time](https://en.wikipedia.org/wiki/Unix_time) seconds) at which the
current rate limiting will expire, resetting to a fresh `X-RateLimit-Limit`
Example:
curl -i https://archive.softwareheritage.org/api/1/stat/counters/ | grep ^X-RateLimit
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 54
X-RateLimit-Reset: 1485794532
diff --git a/swh/web/templates/layout.html b/swh/web/templates/layout.html
index 3e44112e..7e97ead2 100644
--- a/swh/web/templates/layout.html
+++ b/swh/web/templates/layout.html
@@ -1,79 +1,79 @@
{% load static %}
{% block title %}{% endblock %}
{% block header %}{% endblock %}
-
+
-
+